# -*- coding: utf-8 -*-
#
# Copyright (C) 2021 - 2025 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

import json
import math
import os
from pathlib import Path
import shutil
import sys

import defusedxml
from defusedxml.ElementTree import ParseError
import numpy as np

from ansys.aedt.core.aedt_logger import pyaedt_logger as logger
from ansys.aedt.core.base import PyAedtBase
from ansys.aedt.core.generic.constants import AEDT_UNITS
from ansys.aedt.core.generic.constants import SpeedOfLight
from ansys.aedt.core.generic.constants import unit_converter
from ansys.aedt.core.generic.file_utils import open_file
from ansys.aedt.core.generic.general_methods import conversion_function
from ansys.aedt.core.generic.general_methods import pyaedt_function_handler
from ansys.aedt.core.generic.numbers_utils import decompose_variable_value
from ansys.aedt.core.internal.checks import graphics_required
from ansys.aedt.core.visualization.advanced.touchstone_parser import read_touchstone
from ansys.aedt.core.visualization.plot.matplotlib import ReportPlotter
from ansys.aedt.core.visualization.plot.matplotlib import is_notebook
from ansys.aedt.core.visualization.plot.pyvista import ModelPlotter
from ansys.aedt.core.visualization.plot.pyvista import get_structured_mesh

defusedxml.defuse_stdlib()


class FfdSolutionData(PyAedtBase):
    """Provides antenna far-field data.

    Read element pattern information in a JSON file generated by :func:`FfdSolutionDataExporter` and return the
    Python interface to plot and analyze the far-field data.

    Parameters
    ----------
    input_file : str
        Metadata information in a JSON file.
    frequency : float, optional
        Active frequency in hertz (Hz). The default is ``None``, in which case the first frequency is active.
    variation : str, optional
            Label to identify corresponding variation.
    model_info : dict, optional
    incident_power : dict, optional
        Dictionary with information of the incident power for each frequency.
        The default is ``None``, in which case an empty dictionary is applied.
        From AEDT 2024.1, this information is available from the XML input file.
        For example, the dictionary format for a two element farfield
        data = incident_power["1GHz"]
        data = [1, 0.99]
    touchstone_file : str, optional
        Touchstone file name. The default is ``None``.

    Examples
    --------
    >>> from ansys.aedt.core import Hfss
    >>> from ansys.aedt.core.visualization.advanced.farfield_visualization import FfdSolutionData
    >>> app = ansys.aedt.core.Hfss(version="2025.2", design="Antenna")
    >>> data = app.get_antenna_data()
    >>> metadata_file = data.metadata_file
    >>> app.desktop_class.close_desktop()
    >>> farfield_data = FfdSolutionData(input_file=metadata_file)
    >>> farfield_data.plot_3d(quantity_format="dB10")
    """

    def __init__(
        self, input_file, frequency=None, variation=None, model_info=None, incident_power=None, touchstone_file=None
    ):
        if isinstance(input_file, Path):
            input_file = str(input_file)

        input_file_format = str(Path(input_file).suffix).strip(".")

        # Public
        self.output_dir = str(Path(input_file).parent)

        if input_file_format in ["txt", "xml"]:
            if not variation:
                variation = ""
            if not model_info:
                model_info = {}
            if not incident_power:
                incident_power = {}
            if not touchstone_file:
                touchstone_file = ""

            input_file = export_pyaedt_antenna_metadata(
                input_file=input_file,
                output_dir=self.output_dir,
                variation=variation,
                model_info=model_info,
                power=incident_power,
                touchstone_file=touchstone_file,
            )

        self.jupyter_backend = None
        use_html_backend = os.environ.get("PYANSYS_VISUALIZER_HTML_BACKEND", "false").lower() == "true"
        if use_html_backend:
            self.jupyter_backend = "html"

        # Protected
        self._mesh = None

        if not Path(input_file).is_file():  # pragma: no cover
            raise Exception("JSON file does not exist.")

        # Private
        self.__logger = logger
        self.__input_file = input_file
        self.__raw_data = {}
        self.__freq_index = 0
        self.__model_units = "meter"

        self.__element_info = {}
        self.__frequencies = []
        self.__all_element_names = []
        self.__phase = {}
        self.__magnitude = {}
        self.__origin = []
        self.__taper = None
        self.__model_info = {}
        self.__is_array = False
        self.__component_objects = None
        self.__array_dimension = None
        self.__cell_position = None
        self.__lattice_vector = None
        self.__a_min = sys.maxsize
        self.__a_max = 0
        self.__b_min = sys.maxsize
        self.__b_max = 0
        self.__touchstone_data = None
        self.__weight = {}
        self.__phi_scan = 0.0
        self.__theta_scan = 0.0

        self.__incident_power_element = {}

        with open_file(input_file) as f:
            self.__metadata = json.load(f)

        if not self.metadata:  # pragma: no cover
            raise Exception("Metadata could not be loaded..")

        elements = self.metadata["element_pattern"]
        for element_name, element_props in elements.items():
            location = element_props["location"]
            pattern_file = str(Path(self.output_dir) / element_props["file_name"])
            incident_power = element_props["incident_power"]
            accepted_power = element_props["accepted_power"]
            radiated_power = element_props["radiated_power"]

            new_incident_power = {}

            if incident_power:
                for power_freq in incident_power:
                    value = incident_power[power_freq]
                    frequency = power_freq
                    if isinstance(power_freq, str):
                        frequency, units = decompose_variable_value(power_freq)
                        if units:  # pragma: no cover
                            frequency = unit_converter(frequency, "Freq", units, "Hz")
                    new_incident_power[frequency] = value

            new_radiated_power = {}
            if radiated_power:
                for power_freq in radiated_power:
                    frequency = power_freq
                    if isinstance(power_freq, str):
                        frequency, units = decompose_variable_value(power_freq)
                        if units:  # pragma: no cover
                            frequency = unit_converter(frequency, "Freq", units, "Hz")
                    new_radiated_power[frequency] = radiated_power[power_freq]

            new_accepted_power = {}
            if accepted_power:
                for power_freq in accepted_power:
                    frequency = power_freq
                    if isinstance(power_freq, str):
                        frequency, units = decompose_variable_value(power_freq)
                        if units:  # pragma: no cover
                            frequency = unit_converter(frequency, "Freq", units, "Hz")
                    new_accepted_power[frequency] = accepted_power[power_freq]

            self.__element_info[element_name] = {
                "pattern_file": pattern_file,
                "location": [float(location[0]), float(location[1]), float(location[2])],
                "incident_power": new_incident_power,
                "accepted_power": new_accepted_power,
                "radiated_power": new_radiated_power,
            }

        if not self.element_info:  # pragma: no cover
            raise Exception("Wrong farfield file load.")

        # Update properties with the loaded information
        self.__all_element_names = list(self.element_info.keys())
        for element in self.all_element_names:
            self.__magnitude[element] = 1.0
            self.__phase[element] = 0.0

        self.__origin = [0, 0, 0]
        self.__taper = "flat"

        # Load farfield data
        is_farfield_loaded = self.__init_ffd(self.element_info)
        if not is_farfield_loaded:  # pragma: no cover
            raise Exception("Farfield information from ffd files can not be loaded.")

        # Load touchstone data
        metadata_touchstone = Path(self.output_dir) / self.metadata.get("touchstone_file", None)

        if not touchstone_file:
            touchstone_file = metadata_touchstone

        if Path(touchstone_file).exists() and Path(touchstone_file).is_file():
            self.__touchstone_data = read_touchstone(touchstone_file)

        required_array_keys = ["array_dimension", "component_objects", "lattice_vector", "cell_position"]
        if all(key in self.metadata for key in required_array_keys):
            self.__is_array = True
            self.__component_objects = self.metadata["component_objects"]
            self.__array_dimension = self.metadata["array_dimension"]
            self.__cell_position = self.metadata["cell_position"]
            self.__lattice_vector = self.metadata["lattice_vector"]
        else:
            self.__is_array = False

        # Get element indices
        port_indices = self.get_port_index()
        if not port_indices:  # pragma: no cover
            raise Exception("Wrong port index load.")

        # Update active frequency if passed in the initialization
        if frequency and frequency in self.frequencies:
            freq_index = self.frequencies.index(frequency)
            self.frequency = self.frequencies[freq_index]
        else:
            self.frequency = self.frequencies[0]

        port_indices_array = np.array(list(port_indices.values()))

        rows = port_indices_array[:, 0] - 1
        cols = port_indices_array[:, 1] - 1

        self.__a_min = min(self.__a_min, np.min(rows))
        self.__a_max = max(self.__a_max, np.max(rows))
        self.__b_min = min(self.__b_min, np.min(cols))
        self.__b_max = max(self.__b_max, np.max(cols))

    @property
    def phi_scan(self):
        """Phi scan angle in degrees. It applies only for arrays."""
        return self.__phi_scan

    @phi_scan.setter
    def phi_scan(self, value):
        self.__phi_scan = value
        self.__element_weight()

    @property
    def theta_scan(self):
        """Theta scan angle in degrees. It applies only for arrays."""
        return self.__theta_scan

    @theta_scan.setter
    def theta_scan(self, value):
        self.__theta_scan = value
        self.__element_weight()

    @property
    def metadata(self):
        """Antenna metadata."""
        return self.__metadata

    @property
    def touchstone_data(self):
        """Touchstone data."""
        return self.__touchstone_data

    @property
    def s_parameters(self):
        """Passive s-parameters."""
        if self.touchstone_data:
            touchstone_frequencies = self.touchstone_data.f
            index = np.abs(touchstone_frequencies - self.frequency).argmin()
            return self.touchstone_data.s[index]

    @property
    def incident_power_element(self):
        """Incident power per element in watts."""
        incident_power = {}
        for element_name, element_props in self.element_info.items():
            element_power = element_props.get("incident_power", None)
            if element_power and element_power.get(self.frequency, None):
                incident_power[element_name] = element_power.get(self.frequency) * self.magnitude[element_name]
            else:  # pragma: no cover
                incident_power[element_name] = 1.0 * self.magnitude[element_name]
        self.__incident_power_element = incident_power
        return self.__incident_power_element

    @property
    def incident_power(self):
        """Total incident power in watts."""
        incident_power_element = self.incident_power_element
        if incident_power_element:
            return sum(incident_power_element.values())

    @property
    def accepted_power_element(self):
        """Accepted power per element in watts."""
        power = {}
        for element_name, element_props in self.element_info.items():
            element_power = element_props.get("accepted_power", None)
            if element_power and element_power.get(self.frequency, None):
                power[element_name] = element_power.get(self.frequency) * self.magnitude[element_name]
            else:  # pragma: no cover
                power[element_name] = 1.0 * self.magnitude[element_name]
        return power

    @property
    def accepted_power(self):
        """Total accepted power in watts."""
        power_element = self.accepted_power_element
        if power_element:
            return sum(power_element.values())

    @property
    def radiated_power_element(self):
        """Radiated power per element in watts."""
        power = {}
        for element_name, element_props in self.element_info.items():
            element_power = element_props.get("radiated_power", None)
            if element_power and element_power.get(self.frequency, None):
                power[element_name] = element_power.get(self.frequency) * self.magnitude[element_name]
            else:  # pragma: no cover
                power[element_name] = 1.0 * self.magnitude[element_name]
        return power

    @property
    def radiated_power(self):
        """Total radiated power in watts."""
        power_element = self.radiated_power_element
        if power_element:
            return sum(power_element.values())

    @property
    def active_s_parameters(self):
        """Active s-parameters."""
        if self.s_parameters is not None:
            active_s_parameter = {}
            incident_power_list = list(self.incident_power_element.values())
            phase_list = list(self.phase.values())

            for element_cont, element in enumerate(self.all_element_names):
                row_s_parameters = self.s_parameters[element_cont]
                active_s_parameter[element] = 0

                active_amplitude_mag = np.sqrt(incident_power_list[element_cont])
                active_amplitude_phase = phase_list[element_cont]
                active_amplitude_real = active_amplitude_mag * np.cos(active_amplitude_phase)
                active_amplitude_imag = active_amplitude_mag * np.sin(active_amplitude_phase)
                active_amplitude = active_amplitude_real + 1j * active_amplitude_imag

                for s_param_cont, s_parameter_value in enumerate(row_s_parameters):
                    amplitude_mag = np.sqrt(incident_power_list[s_param_cont])
                    amplitude_phase = phase_list[s_param_cont]
                    amplitude_real = amplitude_mag * np.cos(amplitude_phase)
                    amplitude_imag = amplitude_mag * np.sin(amplitude_phase)
                    amplitude = amplitude_real + 1j * amplitude_imag

                    if active_amplitude == 0:
                        active_s_parameter[element] = None
                    else:
                        active_s_parameter[element] += s_parameter_value * amplitude / active_amplitude
            return active_s_parameter

    @property
    def input_file(self):
        """Input file."""
        return self.__input_file

    @property
    def farfield_data(self):
        """Farfield data."""
        return self.combine_farfield(self.theta_scan, self.phi_scan)

    @property
    def element_info(self):
        """File information."""
        return self.__element_info

    @property
    def frequencies(self):
        """Available frequencies."""
        return self.__frequencies

    @property
    def all_element_names(self):
        """Available port names."""
        return self.__all_element_names

    @property
    def weight(self):
        """Weight."""
        return self.__weight

    @property
    def frequency(self):
        """Active frequency."""
        return self._frequency

    @frequency.setter
    def frequency(self, val):
        if isinstance(val, str):
            frequency, units = decompose_variable_value(val)
            unit_converter(frequency, "Freq", units, "Hz")
            val = frequency
        if val in self.frequencies:
            self._frequency = val
            self.__freq_index = self.frequencies.index(val)
        else:  # pragma: no cover
            self.__logger.error("Frequency not available.")

    @property
    def phase(self):
        """Phase offset in degrees on each port."""
        return self.__phase

    @phase.setter
    def phase(self, phases):
        if len(phases) != len(self.all_element_names):
            self.__logger.error("Number of phases must be equal to number of ports.")
        else:
            self.__phase = phases

    @property
    def magnitude(self):
        """Magnitude weight applied on each port."""
        return self.__magnitude

    @magnitude.setter
    def magnitude(self, mags):
        if len(mags) != len(self.all_element_names):
            self.__logger.error("Number of magnitude values must be equal to number of ports.")
        else:
            self.__magnitude = mags

    @property
    def taper(self):
        """Taper type.

        Options are:

        - ``"cosine"``
        - ``"flat"``
        - ``"hamming"``
        - ``"triangular"``
        - ``"uniform"``
        """
        return self.__taper

    @taper.setter
    def taper(self, val):
        if val.lower() in ("flat", "uniform", "cosine", "triangular", "hamming"):
            self.__taper = val

        else:
            self.__logger.error("This taper is not implemented")

    @property
    def origin(self):
        """Far field origin in meters."""
        return self.__origin

    @origin.setter
    def origin(self, vals):
        if len(vals) != 3:
            self.__logger.error("Origin is wrong.")
        else:
            self.__origin = vals

    @pyaedt_function_handler()
    def combine_farfield(self, phi_scan=0.0, theta_scan=0.0):
        """Compute the far field pattern calculated for a specific phi and theta scan angle requested.

        Parameters
        ----------
        phi_scan : float, optional
            Phi scan angle in degrees. The default is ``0.0``.
        theta_scan : float, optional
            Theta scan angle in degrees. The default is ``0.0``.

        Returns
        -------
        dict
            Far field data dictionary.
        """
        # Modify theta and phi and compute weight

        self.__theta_scan = theta_scan
        self.__phi_scan = phi_scan
        self.__element_weight()

        port_positions = {}

        for port_name in self.all_element_names:
            port_positions[port_name] = self.element_info[port_name]["location"]

        # Combine farfield of each port
        initial_port = self.all_element_names[0]
        freq_name_key = self.frequencies[self.__freq_index]
        data = self.__raw_data[initial_port][freq_name_key]
        length_of_ff_data = len(data["rETheta"])
        theta_range = data["Theta"]
        phi_range = data["Phi"]
        n_theta = len(theta_range)
        n_phi = len(phi_range)

        incident_power = self.incident_power
        radiated_power = self.radiated_power
        accepted_power = self.accepted_power

        ph, th = np.meshgrid(data["Phi"], data["Theta"])
        ph = np.deg2rad(ph)
        th = np.deg2rad(th)
        k = 2 * np.pi * self.frequency / SpeedOfLight
        kx_grid = k * np.sin(th) * np.cos(ph)
        ky_grid = k * np.sin(th) * np.sin(ph)
        kz_grid = k * np.cos(th)
        kx_flat = kx_grid.ravel()
        ky_flat = ky_grid.ravel()
        kz_flat = kz_grid.ravel()
        rEphi_fields_sum = np.zeros(length_of_ff_data, dtype=complex)
        rETheta_fields_sum = np.zeros(length_of_ff_data, dtype=complex)

        # Farfield superposition
        for _, port in enumerate(self.all_element_names):
            data = self.__raw_data[port][freq_name_key]
            if port not in self.weight:  # pragma: no cover
                self.__weight[port] = np.sqrt(0) * np.exp(1j * 0)

            xyz_pos = port_positions[port]
            weight = self.weight[port]
            array_factor = np.exp(1j * (xyz_pos[0] * kx_flat + xyz_pos[1] * ky_flat + xyz_pos[2] * kz_flat)) * weight
            rEphi_fields_sum += array_factor * data["rEPhi"]
            rETheta_fields_sum += array_factor * data["rETheta"]

        # Farfield origin shift
        origin = self.origin
        array_factor = np.exp(-1j * (origin[0] * kx_flat + origin[1] * ky_flat + origin[2] * kz_flat))
        rETheta_fields_sum = array_factor * rETheta_fields_sum
        rEphi_fields_sum = array_factor * rEphi_fields_sum

        rEtheta_fields_sum = np.reshape(rETheta_fields_sum, (n_theta, n_phi))
        rEphi_fields_sum = np.reshape(rEphi_fields_sum, (n_theta, n_phi))

        farfield_data = {}
        farfield_data["rEPhi"] = rEphi_fields_sum
        farfield_data["rETheta"] = rEtheta_fields_sum
        farfield_data["rETotal"] = np.sqrt(
            np.power(np.abs(rEphi_fields_sum), 2) + np.power(np.abs(rEtheta_fields_sum), 2)
        )
        farfield_data["Theta"] = theta_range
        farfield_data["Phi"] = phi_range
        farfield_data["nPhi"] = n_phi
        farfield_data["nTheta"] = n_theta

        real_gain = 2 * np.pi * np.abs(np.power(farfield_data["rETotal"], 2)) / incident_power / 377
        farfield_data["RealizedGain"] = real_gain
        farfield_data["RealizedGain_Total"] = real_gain
        farfield_data["RealizedGain_dB"] = 10 * np.log10(real_gain)
        real_gain = 2 * np.pi * np.abs(np.power(farfield_data["rETheta"], 2)) / incident_power / 377
        farfield_data["RealizedGain_Theta"] = real_gain
        real_gain = 2 * np.pi * np.abs(np.power(farfield_data["rEPhi"], 2)) / incident_power / 377
        farfield_data["RealizedGain_Phi"] = real_gain

        gain = 2 * np.pi * np.abs(np.power(farfield_data["rETotal"], 2)) / accepted_power / 377
        farfield_data["Gain"] = gain
        farfield_data["Gain_Total"] = gain
        farfield_data["Gain_dB"] = 10 * np.log10(gain)
        gain = 2 * np.pi * np.abs(np.power(farfield_data["rETheta"], 2)) / accepted_power / 377
        farfield_data["Gain_Theta"] = gain
        gain = 2 * np.pi * np.abs(np.power(farfield_data["rEPhi"], 2)) / accepted_power / 377
        farfield_data["Gain_Phi"] = gain

        directivity = 2 * np.pi * np.abs(np.power(farfield_data["rETotal"], 2)) / radiated_power / 377
        farfield_data["Directivity"] = directivity
        farfield_data["Directivity_Total"] = directivity
        farfield_data["Directivity_dB"] = 10 * np.log10(directivity)
        directivity = 2 * np.pi * np.abs(np.power(farfield_data["rETheta"], 2)) / radiated_power / 377
        farfield_data["Directivity_Theta"] = directivity
        directivity = 2 * np.pi * np.abs(np.power(farfield_data["rEPhi"], 2)) / radiated_power / 377
        farfield_data["Directivity_Phi"] = directivity

        # farfield_data["Element_Location"] = port_positions
        return farfield_data

    @pyaedt_function_handler()
    def get_accepted_power(self):
        """Compute the accepted power from active s-parameters and incident power.

        Returns
        -------
        float
            Total accepted power.
        """
        if self.active_s_parameters is not None:
            accepted_power = {}
            for _, element in enumerate(self.all_element_names):
                if self.active_s_parameters[element] is not None:
                    operation = 1 - np.power(np.abs(self.active_s_parameters[element]), 2)
                    accepted_power[element] = self.incident_power_element[element] * operation
                else:
                    accepted_power[element] = 0.0
            total_accepted_power = sum(accepted_power.values())
            return total_accepted_power

    @pyaedt_function_handler()
    def __assign_weight_taper(self, a, b):  # pragma: no cover
        """Assign weight to array.

        Parameters
        ----------
        a : int
            Index of array, column.
        b : int
            Index of array, row.

        Returns
        -------
        float
            Weight applied to specific index of the array.
        """
        taper = self.taper

        if taper.lower() in ("flat", "uniform") or not self.__is_array:
            return 1

        cosinePow = 1
        edgeTaper_dB = -200

        edgeTaper = 10 ** ((float(edgeTaper_dB)) / 20)

        threshold = 1e-10

        # find the distance between current cell and array center in terms of index
        lattice_vector = self.__lattice_vector[self.__freq_index]
        if not lattice_vector or not len(lattice_vector) == 6:
            return 1.0

        center_a = (self.__a_min + self.__a_max) / 2
        center_b = (self.__b_min + self.__b_max) / 2

        length_in_direction1 = a - center_a
        length_in_direction2 = b - center_b
        max_length_in_dir1 = self.__a_max - self.__a_min
        max_length_in_dir2 = self.__b_max - self.__b_min

        if taper.lower() == "cosine":  # Cosine
            if max_length_in_dir1 < threshold:
                w1 = 1
            else:
                w1 = (1 - edgeTaper) * (
                    math.cos(math.pi * length_in_direction1 / max_length_in_dir1)
                ) ** cosinePow + edgeTaper
            if max_length_in_dir2 < threshold:
                w2 = 1
            else:
                w2 = (1 - edgeTaper) * (
                    math.cos(math.pi * length_in_direction2 / max_length_in_dir2)
                ) ** cosinePow + edgeTaper
        elif taper.lower() == "triangular":  # Triangular
            if max_length_in_dir1 < threshold:
                w1 = 1
            else:
                w1 = (1 - edgeTaper) * (1 - (math.fabs(length_in_direction1) / (max_length_in_dir1 / 2))) + edgeTaper
            if max_length_in_dir2 < threshold:
                w2 = 1
            else:
                w2 = (1 - edgeTaper) * (1 - (math.fabs(length_in_direction2) / (max_length_in_dir2 / 2))) + edgeTaper
        elif taper.lower() == "hamming":  # Hamming Window
            if max_length_in_dir1 < threshold:
                w1 = 1
            else:
                w1 = 0.54 - 0.46 * math.cos(2 * math.pi * (length_in_direction1 / max_length_in_dir1 - 0.5))
            if max_length_in_dir2 < threshold:
                w2 = 1
            else:
                w2 = 0.54 - 0.46 * math.cos(2 * math.pi * (length_in_direction2 / max_length_in_dir2 - 0.5))
        else:
            return 0

        return w1 * w2

    @pyaedt_function_handler()
    def __phase_shift_steering(self, a, b, theta=0.0, phi=0.0):
        """Shift element phase for a specific Theta and Phi scan angle in degrees.

        This method calculates phase shifts between array elements in A and B directions given the lattice vector.

        Parameters
        ----------
        a : int
            Index of array, column.
        b : int
            Index of array, row.
        theta : float, optional
            Theta scan angle in degrees. The default is ``0.0``.
        phi : float, optional
            Phi scan angle in degrees. The default is ``0.0``.

        Returns
        -------
        float
            Phase shift in degrees.
        """
        k = (2 * math.pi * self.frequency) / SpeedOfLight
        a = int(a)
        b = int(b)
        theta = np.deg2rad(theta)
        phi = np.deg2rad(phi)

        lattice_vector = self.__lattice_vector

        a_x, a_y, b_x, b_y = [lattice_vector[0], lattice_vector[1], lattice_vector[3], lattice_vector[4]]

        phase_shift_a = -((a_x * k * np.sin(theta) * np.cos(phi)) + (a_y * k * np.sin(theta) * np.sin(phi)))

        phase_shift_b = -((b_x * k * np.sin(theta) * np.cos(phi)) + (b_y * k * np.sin(theta) * np.sin(phi)))

        phase_shift = a * phase_shift_a + b * phase_shift_b

        return np.rad2deg(phase_shift)

    @pyaedt_function_handler()
    def __element_weight(self):
        # Obtain weights for each element
        for element_name in self.all_element_names:
            amplitude = self.magnitude[element_name]
            phase = self.phase[element_name]
            if self.__is_array:
                index_port = self.get_port_index()
                index_str = index_port[element_name]
                a = index_str[0] - 1
                b = index_str[1] - 1
                phase_steering = self.__phase_shift_steering(a, b, self.theta_scan, self.phi_scan)
                phase += phase_steering
                amplitude_taper = self.__assign_weight_taper(a=a, b=b)
                amplitude *= amplitude_taper

            self.__weight[element_name] = np.sqrt(amplitude) * np.exp(1j * np.deg2rad(phase))
            self.__magnitude[element_name] = amplitude
            self.__phase[element_name] = phase

    @pyaedt_function_handler()
    def plot_contour(
        self,
        quantity="RealizedGain",
        phi=0,
        theta=0,
        title=None,
        quantity_format="dB10",
        output_file=None,
        levels=64,
        polar=True,
        max_theta=180,
        show=True,
    ):
        """Create a contour plot of a specified quantity in Matplotlib.

        Parameters
        ----------
        quantity : str, optional
            Far field quantity to plot. The default is ``"RealizedGain"``.
            Available quantities are: ``"RealizedGain"``, ``"RealizedGain_Phi"``, ``"RealizedGain_Theta"``,
            ``"rEPhi"``, ``"rETheta"``, and ``"rETotal"``.
        phi : float, int, optional
            Phi scan angle in degrees. The default is ``0``.
        theta : float, int, optional
            Theta scan angle in degrees. The default is ``0``.
        title : str, optional
            Plot title. The default is ``"RectangularPlot"``.
        quantity_format : str, optional
            Conversion data function.
            Available functions are: ``"abs"``, ``"ang"``, ``"dB10"``, ``"dB20"``, ``"deg"``, ``"imag"``, ``"norm"``,
            and ``"real"``.
        output_file : str, optional
            Full path for the image file. The default is ``None``, in which case the file is not exported.
        levels : int, optional
            Color map levels. The default is ``64``.
        show : bool, optional
            Whether to show the plot. The default is ``True``. If ``False``, the Matplotlib
            instance of the plot is shown.
        polar : bool, optional
            Generate the plot in polar coordinates. The default is ``True``. If ``False``, the plot
            generated is rectangular.
        max_theta : float or int, optional
            Maximum theta angle for plotting. The default is ``180``, which plots the far-field data for
            all angles. Setting ``max_theta`` to 90 limits the displayed data to the upper
            hemisphere, that is (0 < theta < 90).

        Returns
        -------
        :class:`ansys.aedt.core.visualization.plot.matplotlib.ReportPlotter`
            Matplotlib figure object.

        Examples
        --------
        >>> from ansys.aedt.core
        >>> app = ansys.aedt.core.Hfss(version="2025.2", design="Antenna")
        >>> setup_name = "Setup1 : LastAdaptive"
        >>> frequencies = [77e9]
        >>> sphere = "3D"
        >>> data = app.get_antenna_data(frequencies, setup_name, sphere)
        >>> data.plot_contour()

        """
        if not title:
            title = quantity

        data = self.combine_farfield(phi, theta)
        if quantity not in data:  # pragma: no cover
            raise Exception("Far field quantity is not available.")

        select = np.abs(data["Theta"]) <= max_theta  # Limit theta range for plotting.

        data_to_plot = data[quantity][select, :]
        data_to_plot = conversion_function(data_to_plot, quantity_format)
        if not isinstance(data_to_plot, np.ndarray):  # pragma: no cover
            raise Exception("Wrong format quantity.")

        ph, th = np.meshgrid(data["Phi"], data["Theta"][select])
        # Convert to radians for polar plot.
        ph = np.radians(ph) if polar else ph
        th = np.radians(th) if polar else th

        new = ReportPlotter()
        new.show_legend = False
        new.title = title
        props = {
            "x_label": r"$\phi$ (Degrees)",
            "y_label": r"$\theta$ (Degrees)",
        }

        new.add_trace([data_to_plot, th, ph], 1, props)

        _ = new.plot_contour(
            trace=0,
            polar=polar,
            levels=levels,
            max_theta=max_theta,
            color_bar=quantity_format,
            snapshot_path=output_file,
            show=show,
        )
        return new

    @pyaedt_function_handler()
    def plot_cut(
        self,
        quantity="RealizedGain",
        primary_sweep="phi",
        secondary_sweep_value=0,
        phi=0,
        theta=0,
        title="Far Field Cut",
        quantity_format="dB10",
        output_file=None,
        show=True,
        is_polar=False,
        show_legend=True,
    ):
        """Create a 2D plot of a specified quantity in Matplotlib.

        Parameters
        ----------
        quantity : str, optional
            Quantity to plot. The default is ``"RealizedGain"``.
            Available quantities are: ``"RealizedGain"``, ``"RealizedGain_Theta"``, ``"RealizedGain_Phi"``,
            ``"rETotal"``, ``"rETheta"``, and ``"rEPhi"``.
        primary_sweep : str, optional.
            X-axis variable. The default is ``"phi"``. Options are ``"phi"`` and ``"theta"``.
        secondary_sweep_value : float, list, string, optional
            List of cuts on the secondary sweep to plot. The default is ``0``. Options are
            `"all"`, a single value float, or a list of float values.
        phi : float, int, optional
            Phi scan angle in degrees. The default is ``0``.
        theta : float, int, optional
            Theta scan angle in degrees. The default is ``0``.
        title : str, optional
            Plot title. The default is ``"RectangularPlot"``.
        quantity_format : str, optional
            Conversion data function.
            Available functions are: ``"abs"``, ``"ang"``, ``"dB10"``, ``"dB20"``, ``"deg"``, ``"imag"``, ``"norm"``,
            and ``"real"``.
        output_file : str, optional
            Full path for the image file. The default is ``None``, in which case an image in not exported.
        show : bool, optional
            Whether to show the plot. The default is ``True``.
            If ``False``, the Matplotlib instance of the plot is shown.
        is_polar : bool, optional
            Whether this plot is a polar plot. The default is ``True``.
        show_legend : bool, optional
            Whether to display the legend or not. The default is ``True``.

        Returns
        -------
        :class:`ansys.aedt.core.visualization.plot.matplotlib.ReportPlotter`
            Matplotlib figure object.
            If ``show=True``, a Matplotlib figure instance of the plot is returned.
            If ``show=False``, the plotted curve is returned.

        Examples
        --------
        >>> from ansys.aedt.core
        >>> app = ansys.aedt.core.Hfss(version="2025.2", design="Antenna")
        >>> setup_name = "Setup1 : LastAdaptive"
        >>> frequencies = [77e9]
        >>> sphere = "3D"
        >>> data = app.get_antenna_data(frequencies, setup_name, sphere)
        >>> data.plot_cut(theta=20)
        """
        if isinstance(output_file, Path):
            output_file = str(output_file)
        data = self.combine_farfield(phi, theta)
        if quantity not in data:  # pragma: no cover
            raise Exception("Far field quantity not available.")

        data_to_plot = data[quantity]

        curves = []
        if primary_sweep.lower() == "phi":
            x_key, y_key = "Phi", "Theta"
            temp = data_to_plot
        else:
            y_key, x_key = "Phi", "Theta"
            temp = data_to_plot.T
        x = data[x_key]
        if secondary_sweep_value == "all":
            for el in data[y_key]:
                idx = self.__find_nearest(data[y_key], el)
                y = temp[idx]
                y = conversion_function(y, quantity_format)
                if not isinstance(y, np.ndarray):  # pragma: no cover
                    raise Exception("Format of quantity is wrong.")
                curves.append([x, y, f"{y_key}={el}"])
        elif isinstance(secondary_sweep_value, list):
            list_inserted = []
            for el in secondary_sweep_value:
                theta_idx = self.__find_nearest(data[y_key], el)
                if theta_idx not in list_inserted:
                    y = temp[theta_idx]
                    y = conversion_function(y, quantity_format)
                    if not isinstance(y, np.ndarray):  # pragma: no cover
                        raise Exception("Format of quantity is wrong.")
                    curves.append([x, y, f"{y_key}={el}"])
                    list_inserted.append(theta_idx)
        else:
            theta_idx = self.__find_nearest(data[y_key], secondary_sweep_value)
            y = temp[theta_idx]
            y = conversion_function(y, quantity_format)
            if not isinstance(y, np.ndarray):  # pragma: no cover
                raise Exception("Wrong format quantity.")
            curves.append([x, y, f"{y_key}={data[y_key][theta_idx]}"])

        new = ReportPlotter()
        new.show_legend = show_legend
        new.title = title
        props = {"x_label": x_key, "y_label": quantity}
        for pdata in curves:
            name = pdata[2] if len(pdata) > 2 else "Trace"
            new.add_trace(pdata[:2], 0, props, name=name)
        if is_polar:
            _ = new.plot_polar(traces=None, snapshot_path=output_file, show=show)
        else:
            _ = new.plot_2d(None, output_file, show)
        return new

    @pyaedt_function_handler()
    def plot_3d_chart(
        self,
        quantity="RealizedGain",
        phi=0,
        theta=0,
        title="3D Plot",
        quantity_format="dB10",
        output_file=None,
        show=True,
    ):
        """Create a 3D chart of a specified quantity in Matplotlib.

        Parameters
        ----------
        quantity : str, optional
            Far field quantity to plot. The default is ``"RealizedGain"``.
            Available quantities are: ``"RealizedGain"``, ``"RealizedGain_Phi"``, ``"RealizedGain_Theta"``,
            ``"rEPhi"``, ``"rETheta"``, and ``"rETotal"``.
        phi : float, int, optional
            Phi scan angle in degree. The default is ``0``.
        theta : float, int, optional
            Theta scan angle in degree. The default is ``0``.
        title : str, optional
            Plot title. The default is ``"3D Plot"``.
        quantity_format : str, optional
            Conversion data function.
            Available functions are: ``"abs"``, ``"ang"``, ``"dB10"``, ``"dB20"``, ``"deg"``, ``"imag"``, ``"norm"``,
            and ``"real"``.
        output_file : str, optional
            Full path for the image file. The default is ``None``, in which case a file is not exported.
        show : bool, optional
            Whether to show the plot. The default is ``True``.
            If ``False``, the Matplotlib instance of the plot is not shown.

        Returns
        -------
        :class:`ansys.aedt.core.visualization.plot.matplotlib.ReportPlotter`
            Matplotlib figure object.


        Examples
        --------
        >>> from ansys.aedt.core
        >>> app = ansys.aedt.core.Hfss(version="2025.2", design="Antenna")
        >>> setup_name = "Setup1 : LastAdaptive"
        >>> frequencies = [77e9]
        >>> sphere = "3D"
        >>> data = app.get_antenna_data(frequencies, setup_name, sphere)
        >>> data.polar_plot_3d(theta=10)
        """
        data = self.combine_farfield(phi, theta)
        if quantity not in data:  # pragma: no cover
            raise Exception("Far field quantity is not available.")

        ff_data = conversion_function(data[quantity], quantity_format)
        if not isinstance(ff_data, np.ndarray):  # pragma: no cover
            raise Exception("Format of the quantity is wrong.")

        # re-normalize to 0 and 1
        ff_max = np.max(ff_data)
        ff_min = np.min(ff_data)

        ff_data_renorm = (ff_data - ff_min) / (ff_max - ff_min)

        theta = np.deg2rad(np.array(data["Theta"]))
        phi = np.deg2rad(np.array(data["Phi"]))
        phi_grid, theta_grid = np.meshgrid(phi, theta)
        r = np.reshape(ff_data_renorm, (len(data["Theta"]), len(data["Phi"])))

        x = r * np.sin(theta_grid) * np.cos(phi_grid)
        y = r * np.sin(theta_grid) * np.sin(phi_grid)
        z = r * np.cos(theta_grid)
        new = ReportPlotter()
        new.show_legend = False
        new.title = title
        props = {"x_label": "Theta", "y_label": "Phi", "z_label": quantity}
        new.add_trace([x, y, z], 2, props, quantity)
        _ = new.plot_3d(trace=0, snapshot_path=output_file, show=show)
        return new

    @pyaedt_function_handler()
    @graphics_required
    def plot_3d(
        self,
        quantity="RealizedGain",
        quantity_format="dB10",
        rotation=None,
        output_file=None,
        show=True,
        show_as_standalone=False,
        pyvista_object=None,
        background=None,
        scale_farfield=None,
        show_beam_slider=True,
        show_geometry=True,
    ):
        """Create a 3D polar plot of the geometry with a radiation pattern in PyVista.

        Parameters
        ----------
        quantity : str, optional
            Quantity to plot. The default is ``"RealizedGain"``.
            Available quantities are: ``"RealizedGain"``, ``"RealizedGain_Theta"``, ``"RealizedGain_Phi"``,
            ``"rETotal"``, ``"rETheta"``, and ``"rEPhi"``.
        quantity_format : str, optional
            Conversion data function.
            Available functions are: ``"abs"``, ``"ang"``, ``"dB10"``, ``"dB20"``, ``"deg"``, ``"imag"``, ``"norm"``,
            and ``"real"``.
        output_file : str, optional
            Full path for the image file. The default is ``None``, in which case a file is not exported.
        rotation : list, optional
            Far field rotation matrix. The matrix contains three vectors, around x, y, and z axes.
            The default is ``[[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]]``.
        show : bool, optional
            Whether to show the plot. The default is ``True``.
        show_as_standalone : bool, optional
            Whether to show a plot as standalone. The default is ``False``.
        pyvista_object : :class:`Pyvista.Plotter`, optional
            PyVista instance defined externally. The default is ``None``.
        background : list or str, optional
            Background color if a list is passed or background picture if a string is passed.
            The default is ``None``.
        scale_farfield : list, optional
            List with minimum and maximum values of the scale slider. The default is
            ``None``.
        show_beam_slider : bool, optional
            Whether the Theta and Phi scan slider is active. The default is ``True``.
        show_geometry :
            Whether to show the geometry. The default is ``True``.

        Returns
        -------
        bool or :class:`Pyvista.Plotter`
            ``True`` when successful. The :class:`Pyvista.Plotter` is returned when ``show`` and
            ``image_path`` are ``False``.

        Examples
        --------
        >>> from ansys.aedt.core
        >>> app = ansys.aedt.core.Hfss(version="2025.2", design="Antenna")
        >>> setup_name = "Setup1 : LastAdaptive"
        >>> frequencies = [77e9]
        >>> sphere = "3D"
        >>> data = app.get_antenna_data(setup=setup_name, sphere=sphere)
        >>> data.plot_3d(quantity_format="dB10")
        """
        import pyvista as pv

        if not rotation:
            rotation = np.eye(3)
        elif isinstance(rotation, (list, tuple)):  # pragma: no cover
            rotation = np.array(rotation)
        text_color = "white"
        if background is None:
            background = [255, 255, 255]
            text_color = "black"

        farfield_data = self.combine_farfield(phi_scan=0, theta_scan=0)
        if quantity not in farfield_data:  # pragma: no cover
            raise Exception("Far field quantity is not available.")

        self._mesh = self.get_far_field_mesh(quantity=quantity, quantity_format=quantity_format)

        rotation_euler = self.__rotation_to_euler_angles(rotation) * 180 / np.pi

        if not output_file and not show:
            off_screen = False
        else:
            off_screen = not show

        if not pyvista_object:
            if show_as_standalone:  # pragma: no cover
                p = pv.Plotter(notebook=False)
            else:
                is_notebook_flag = is_notebook()
                p = pv.Plotter(notebook=is_notebook_flag)
            p.off_screen = off_screen
            p.enable_ssao()
            p.enable_parallel_projection()
        else:  # pragma: no cover
            p = pyvista_object

        uf = UpdateBeamForm(self, quantity, quantity_format)

        default_background = [255, 255, 255]
        axes_color = [i / 255 for i in default_background]

        if isinstance(background, list):
            background_color = [i / 255 for i in background]
            p.background_color = background_color
            axes_color = [0 if i >= 128 else 255 for i in background]
        elif isinstance(background, str):  # pragma: no cover
            p.add_background_image(background, scale=2.5)

        if show_beam_slider and self.__is_array:
            p.add_slider_widget(
                uf.update_phi,
                rng=[0, 360],
                value=0,
                title="Phi",
                pointa=(0.55, 0.1),
                pointb=(0.74, 0.1),
                style="modern",
                interaction_event="always",
                title_height=0.02,
                color=axes_color,
            )
            p.add_slider_widget(
                uf.update_theta,
                rng=[-180, 180],
                value=0,
                title="Theta",
                pointa=(0.77, 0.1),
                pointb=(0.98, 0.1),
                style="modern",
                interaction_event="always",
                title_height=0.02,
                color=axes_color,
            )

        sargs = dict(
            title_font_size=12,
            label_font_size=12,
            shadow=True,
            n_labels=7,
            italic=True,
            fmt="%.1f",
            font_family="arial",
            vertical=True,
            position_x=0.05,
            position_y=0.65,
            height=0.3,
            width=0.06,
            color=axes_color,
            title=None,
            outline=False,
        )

        cad_mesh = self.__get_geometry(off_screen=off_screen)

        data = conversion_function(farfield_data[quantity], function=quantity_format)
        if not isinstance(data, np.ndarray):  # pragma: no cover
            raise Exception("Wrong format quantity.")

        max_data = np.max(data)
        min_data = np.min(data)
        ff_mesh_inst = p.add_mesh(uf.output, cmap="jet", clim=[min_data, max_data], scalar_bar_args=sargs)

        if cad_mesh:  # pragma: no cover

            def toggle_vis_ff(flag):
                ff_mesh_inst.SetVisibility(flag)

            def toggle_vis_cad(flag):
                for i in cad:
                    i.SetVisibility(flag)

            def scale(value=1):
                ff_mesh_inst.SetScale(value, value, value)
                sf = AEDT_UNITS["Length"][self.__model_units]
                ff_mesh_inst.SetPosition(np.divide(self.origin, sf))
                ff_mesh_inst.SetOrientation(rotation_euler)

            p.add_checkbox_button_widget(toggle_vis_ff, value=True, size=30)
            p.add_text("Show Far Fields", position=(70, 25), color=text_color, font_size=10)
            if not scale_farfield:
                if self.__is_array:
                    slider_max = int(np.ceil(np.abs(np.max(self.__array_dimension) / np.min(np.abs(p.bounds)))))
                else:  # pragma: no cover
                    slider_max = int(np.ceil((np.max(p.bounds) / 2 / np.min(np.abs(p.bounds)))))
                slider_min = 0
            else:
                slider_max = scale_farfield[1]
                slider_min = scale_farfield[0]
            value = slider_max / 3

            p.add_slider_widget(
                scale,
                [slider_min, slider_max],
                title="Scale Plot",
                value=value,
                pointa=(0.7, 0.93),
                pointb=(0.99, 0.93),
                style="modern",
                title_height=0.02,
                color=axes_color,
            )

            cad = []
            for cm in cad_mesh:
                cad.append(p.add_mesh(cm[0], color=cm[1], show_scalar_bar=False, opacity=cm[2]))

            if not show_geometry:
                p.add_checkbox_button_widget(toggle_vis_cad, value=False, position=(10, 70), size=30)
                toggle_vis_cad(False)
            else:
                p.add_checkbox_button_widget(toggle_vis_cad, value=True, position=(10, 70), size=30)

            p.add_text("Show Geometry", position=(70, 75), color=text_color, font_size=10)

        if output_file:
            p.show(auto_close=True, screenshot=output_file, full_screen=True, jupyter_backend=self.jupyter_backend)
        elif show:  # pragma: no cover
            p.show(auto_close=False, interactive=True, jupyter_backend=self.jupyter_backend)
        return p

    @pyaedt_function_handler()
    def __init_ffd(self, element_info):
        """Load far field information.

        Parameters
        ----------
        element_info : dict
            Information about the far fields imported.
            The keys of the dictionary represent the element names.

        Returns
        -------
        bool
            ``True`` when successful, ``False`` when failed.
        """
        for element, element_data in element_info.items():
            self.__raw_data[element] = {}
            self.__frequencies = []
            if Path(element_data["pattern_file"]).exists():
                # Extract ports
                with open_file(element_data["pattern_file"], "r") as reader:
                    theta = [int(i) for i in reader.readline().split()]
                    phi = [int(i) for i in reader.readline().split()]
                reader.close()

                # Extract ffd information
                with open(element_data["pattern_file"], "r") as file:
                    ffd_text = file.read()

                segments = ffd_text.split("Frequency")
                eep_text_list = {}
                frequency_text_list = []

                for i, segment in enumerate(segments[1:]):
                    segment = segment.strip()
                    if segment:
                        lines = segment.split("\n")
                        frequency_text_list.append(lines[0])
                        eep_text_list[lines[0]] = lines[1:]

                theta_range = np.linspace(*theta)
                phi_range = np.linspace(*phi)

                for freq in frequency_text_list:
                    freq_hz = float(freq)
                    self.__frequencies.append(freq_hz)
                    temp_dict = {}
                    self.__raw_data[element][freq_hz] = {}
                    eep_txt = np.loadtxt(eep_text_list[freq])
                    Etheta = np.vectorize(complex)(eep_txt[:, 0], eep_txt[:, 1])
                    Ephi = np.vectorize(complex)(eep_txt[:, 2], eep_txt[:, 3])
                    temp_dict["Theta"] = theta_range
                    temp_dict["Phi"] = phi_range
                    temp_dict["rETheta"] = Etheta
                    temp_dict["rEPhi"] = Ephi
                    self.__raw_data[element][freq_hz] = temp_dict
            else:  # pragma: no cover
                raise Exception("Wrong far fields were imported.")

        return True

    @pyaedt_function_handler()
    def get_far_field_mesh(self, quantity="RealizedGain", quantity_format="dB10"):
        """Generate a PyVista ``UnstructuredGrid`` object that represents the far field mesh.

        Parameters
        ----------
        quantity : str, optional
            Far field quantity to plot. The default is ``"RealizedGain"``.
            Available quantities are: ``"RealizedGain"``, ``"RealizedGain_Phi"``, ``"RealizedGain_Theta"``,
            ``"rEPhi"``, ``"rETheta"``, and ``"rETotal"``.
        quantity_format : str, optional
            Conversion data function.
            Available functions are: ``"abs"``, ``"ang"``, ``"dB10"``, ``"dB20"``, ``"deg"``, ``"imag"``, ``"norm"``,
            and ``"real"``.

        Returns
        -------
        :class:`Pyvista.Plotter`
            ``UnstructuredGrid`` object representing the far field mesh.
        """
        farfield_data = self.farfield_data
        if quantity not in farfield_data:  # pragma: no cover
            raise Exception("Far field quantity is not available.")

        data = farfield_data[quantity]

        ff_data = conversion_function(data, quantity_format)

        if not isinstance(ff_data, np.ndarray):  # pragma: no cover
            raise Exception("Format of the quantity is wrong.")

        theta = np.deg2rad(np.array(farfield_data["Theta"]))
        phi = np.deg2rad(np.array(farfield_data["Phi"]))
        mesh = get_structured_mesh(theta=theta, phi=phi, ff_data=ff_data)
        return mesh

    @pyaedt_function_handler()
    def __get_geometry(self, off_screen=False):
        """Get 3D meshes."""
        model_info = self.metadata["model_info"]
        obj_meshes = []
        if self.__is_array:
            non_array_geometry = model_info.copy()
            components_info = self.__component_objects
            array_dimension = self.__array_dimension
            first_value = next(iter(model_info.values()))
            sf = AEDT_UNITS["Length"][first_value[3]]
            self.__model_units = first_value[3]
            cell_info = self.__cell_position

            for cell_row in cell_info:
                for cell_col in cell_row:
                    # Initialize an empty mesh for this component
                    model_pv = ModelPlotter()
                    model_pv.off_screen = off_screen
                    component_name = cell_col[0]
                    component_info = components_info[component_name]
                    rotation = cell_col[2]
                    for component_obj in component_info[1:]:
                        if component_obj in model_info:
                            if component_obj in non_array_geometry:
                                del non_array_geometry[component_obj]

                            cad_path = Path(self.output_dir) / model_info[component_obj][0]
                            if cad_path.exists():
                                model_pv.add_object(
                                    str(cad_path),
                                    model_info[component_obj][1],
                                    model_info[component_obj][2],
                                    model_info[component_obj][3],
                                )

                    model_pv.generate_geometry_mesh()
                    comp_meshes = []
                    row, col = cell_col[3]

                    # Perpendicular lattice vector
                    if self.__lattice_vector[0] != 0:
                        pos_x = (row - 1) * array_dimension[2] - array_dimension[0] / 2 + array_dimension[2] / 2
                        pos_y = (col - 1) * array_dimension[3] - array_dimension[1] / 2 + array_dimension[3] / 2
                    else:
                        pos_y = (row - 1) * array_dimension[2] - array_dimension[0] / 2 + array_dimension[2] / 2
                        pos_x = (col - 1) * array_dimension[3] - array_dimension[1] / 2 + array_dimension[3] / 2

                    for obj in model_pv.objects:
                        mesh = obj._cached_polydata
                        translated_mesh = mesh.copy()
                        color_cad = [i / 255 for i in obj.color]

                        translated_mesh.translate(
                            [-component_info[0][0] / sf, -component_info[0][1] / sf, -component_info[0][2] / sf],
                            inplace=True,
                        )

                        if rotation != 0:
                            translated_mesh.rotate_z(rotation, inplace=True)

                        # Translate the mesh to its position
                        translated_mesh.translate([pos_x / sf, pos_y / sf, component_info[0][2] / sf], inplace=True)

                        comp_meshes.append([translated_mesh, color_cad, obj.opacity])

                    obj_meshes.append(comp_meshes)
                    model_pv.close()

            obj_meshes = [item for sublist in obj_meshes for item in sublist]
        else:
            non_array_geometry = model_info

        if non_array_geometry:  # pragma: no cover
            model_pv = ModelPlotter()
            first_value = next(iter(non_array_geometry.values()))
            sf = AEDT_UNITS["Length"][first_value[3]]
            self.__model_units = first_value[3]
            model_pv.off_screen = off_screen
            for object_in in non_array_geometry.values():
                cad_path = Path(self.output_dir) / object_in[0]
                if cad_path.exists():
                    model_pv.add_object(
                        str(cad_path),
                        object_in[1],
                        object_in[2],
                        object_in[3],
                    )
                else:
                    self.logger.warning(f"{cad_path} does not exist.")
                    return False
            self.__model_units = first_value[3]
            model_pv.generate_geometry_mesh()
            i = 0
            for obj in model_pv.objects:
                mesh = obj._cached_polydata
                translated_mesh = mesh.copy()
                color_cad = [i / 255 for i in obj.color]

                if len(obj_meshes) > i:
                    obj_meshes[i][0] += translated_mesh
                else:
                    obj_meshes.append([translated_mesh, color_cad, obj.opacity])
                i += 1
            model_pv.close()

        return obj_meshes

    @pyaedt_function_handler()
    def get_port_index(self):
        """Get port indices.

        Returns
        -------
        list
            Element index.
        """
        port_index = {}

        port_name = self.all_element_names

        index_offset = 0
        if self.__is_array:
            port = port_name[0]
            first_index = port.split("[", 1)[1].split("]", 1)[0]
            if first_index[0] != "1":
                index_offset = int(float(first_index[0])) - 1

        for port in port_name:
            if self.__is_array:
                try:
                    str1 = port.split("[", 1)[1].split("]", 1)[0]
                    port_index[port] = [int(i) - index_offset for i in str1.split(",")]
                except Exception:
                    return False
            else:
                if not port_index:
                    port_index[port] = [1, 1]
                else:
                    last_value = list(port_index.values())[-1]
                    port_index[port] = [1, last_value[1] + 1]

        return port_index

    @staticmethod
    @pyaedt_function_handler()
    def __find_nearest(array, value):
        idx = np.searchsorted(array, value, side="left")
        if idx > 0 and (idx == len(array) or math.fabs(value - array[idx - 1]) < math.fabs(value - array[idx])):
            return idx - 1
        else:
            return idx

    @staticmethod
    @pyaedt_function_handler()
    def __rotation_to_euler_angles(rotation):  # pragma: no cover
        sy = math.sqrt(rotation[0, 0] * rotation[0, 0] + rotation[1, 0] * rotation[1, 0])
        singular = sy < 1e-6
        if not singular:
            x = math.atan2(rotation[2, 1], rotation[2, 2])
            y = math.atan2(-rotation[2, 0], sy)
            z = math.atan2(rotation[1, 0], rotation[0, 0])
        else:
            x = math.atan2(-rotation[1, 2], rotation[1, 1])
            y = math.atan2(-rotation[2, 0], sy)
            z = 0
        return np.array([x, y, z])


class UpdateBeamForm(PyAedtBase):
    """Provides for updating far field data.

    This class is used to interact with the far field Theta and Phi scan.

    Parameters
    ----------
    farfield_data : :class:`ansys.aedt.core.visualization.advanced.farfield_visualization.FfdSolutionData`
        Far field solution data instance.
    farfield_quantity : str, optional
        Quantity to plot. The default is ``"RealizedGain"``.
        Available quantities are: ``"RealizedGain"``, ``"RealizedGain_Phi"``, ``"RealizedGain_Theta"``,
        ``"rEPhi"``, ``"rETheta"``, and ``"rETotal"``.
    quantity_format : str, optional
        Conversion data function.
        Available functions are: ``"abs"``, ``"ang"``, ``"dB10"``, ``"dB20"``, ``"deg"``, ``"imag"``, ``"norm"``,
            and ``"real"``.
    """

    @pyaedt_function_handler(farfield_quantity="quantity")
    def __init__(self, ff, farfield_quantity="RealizedGain", quantity_format="abs"):
        self.output = ff._mesh
        self.__phi = 0
        self.__theta = 0
        self.ff = ff
        self.quantity = farfield_quantity
        self.quantity_format = quantity_format

    @pyaedt_function_handler()
    def __update_both(self):
        """Update far field."""
        self.ff.__farfield_data = self.ff.combine_farfield(phi_scan=self.__phi, theta_scan=self.__theta)
        self.ff._mesh = self.ff.get_far_field_mesh(self.quantity, self.quantity_format)
        self.output.copy_from(self.ff._mesh)

    @pyaedt_function_handler()
    def update_phi(self, phi):
        """Update the Phi value."""
        self.__phi = phi
        self.__update_both()

    @pyaedt_function_handler()
    def update_theta(self, theta):
        """Update the Theta value."""
        self.__theta = theta
        self.__update_both()


@pyaedt_function_handler()
def export_pyaedt_antenna_metadata(
    input_file, output_dir, variation=None, model_info=None, power=None, touchstone_file=None
):
    """Obtain PyAEDT metadata JSON file from AEDT metadata XML file or embedded element pattern TXT file.

    Parameters
    ----------
    input_file : str
        Full path to the XML or TXT file.
    output_dir : str
        Full path to save the file to.
    variation : str, optional
        Label to identify corresponding variation.
    model_info : dict, optional
    power : dict, optional
        Dictionary with information of the incident power for each frequency.
        The default is ``None``, in which case an empty dictionary is applied.
        From AEDT 2024.1, this information is available from the XML input file.
        For example, the dictionary format for a two element farfield
        data = power[1000000000.0]["IncidentPower"]
        data = [1, 0.99]
    touchstone_file : str, optional
        Touchstone file name. The default is ``None``.

    Returns
    -------
    str
        Metadata JSON file.
    """
    from ansys.aedt.core.visualization.advanced.touchstone_parser import find_touchstone_files

    if isinstance(input_file, Path):
        input_file = str(input_file)

    if not variation:
        variation = "Nominal"

    if not power:
        power = {}

    if not touchstone_file:
        touchstone_file = ""

    pyaedt_metadata_file = Path(output_dir) / "pyaedt_antenna_metadata.json"
    items = {"variation": variation, "element_pattern": {}, "touchstone_file": touchstone_file}

    if Path(input_file).is_file() and Path(input_file).suffix == ".xml":
        # Metadata available from 2024.1
        antenna_metadata = antenna_metadata_from_xml(input_file)

        # Find all ffd files and move them to main directory
        for dir_path, _, filenames in os.walk(output_dir):
            ffd_files = [file for file in filenames if file.endswith(".ffd")]
            sNp_files = find_touchstone_files(dir_path)
            if ffd_files:
                # Move ffd files to main directory
                for ffd_file in ffd_files:
                    output_file = Path(output_dir) / ffd_file
                    pattern_file = Path(dir_path) / ffd_file
                    shutil.move(pattern_file, output_file)
            if sNp_files and not touchstone_file:
                # Only one Touchstone allowed
                sNp_name, sNp_path = next(iter(sNp_files.items()))
                output_file = Path(output_dir) / sNp_name
                exported_touchstone_file = Path(sNp_path)
                shutil.move(exported_touchstone_file, output_file)
                items["touchstone_file"] = sNp_name

        for metadata in antenna_metadata:
            incident_power = {}
            for i_freq, i_power_value in metadata["incident_power"].items():
                frequency = i_freq
                if isinstance(i_freq, str):
                    frequency, units = decompose_variable_value(i_freq)
                    if units:
                        frequency = unit_converter(frequency, "Freq", units, "Hz")
                incident_power[frequency] = float(i_power_value)

            radiated_power = {}
            for i_freq, i_power_value in metadata["radiated_power"].items():
                frequency = i_freq
                if isinstance(i_freq, str):
                    frequency, units = decompose_variable_value(i_freq)
                    if units:
                        frequency = unit_converter(frequency, "Freq", units, "Hz")
                radiated_power[frequency] = float(i_power_value)

            accepted_power = {}
            for i_freq, i_power_value in metadata["accepted_power"].items():
                frequency = i_freq
                if isinstance(i_freq, str):
                    frequency, units = decompose_variable_value(i_freq)
                    if units:
                        frequency = unit_converter(frequency, "Freq", units, "Hz")
                accepted_power[frequency] = float(i_power_value)

            pattern = {
                "file_name": metadata["file_name"],
                "location": metadata["location"],
                "incident_power": incident_power,
                "radiated_power": radiated_power,
                "accepted_power": accepted_power,
            }

            items["element_pattern"][metadata["name"]] = pattern
            pattern_file = Path(output_dir) / metadata["file_name"]
            if not pattern_file.is_file():  # pragma: no cover
                return False

    elif Path(input_file).is_file() and Path(input_file).suffix == ".txt":
        # Find all ffd files and move them to main directory
        for dir_path, _, _ in os.walk(output_dir):
            sNp_files = find_touchstone_files(dir_path)
            if sNp_files and not touchstone_file:
                # Only one Touchstone allowed
                sNp_name, sNp_path = next(iter(sNp_files.items()))
                output_file = Path(output_dir) / sNp_name
                exported_touchstone_file = Path(sNp_path)
                shutil.move(exported_touchstone_file, output_file)
                items["touchstone_file"] = sNp_name
                break

        with open_file(input_file, "r") as file:
            # Skip the first line
            file.readline()
            # Read and process the remaining lines
            for line in file:
                antenna_metadata = line.strip().split()
                if len(antenna_metadata) == 5:
                    element_name = antenna_metadata[0]
                    file_name = antenna_metadata[1]
                    if ".ffd" not in file_name:
                        file_name = file_name + ".ffd"
                    incident_power = None
                    radiated_power = None
                    accepted_power = None
                    if power:
                        incident_power = power[element_name]["IncidentPower"]
                        radiated_power = power[element_name]["RadiatedPower"]
                        accepted_power = power[element_name]["AcceptedPower"]

                    pattern = {
                        "file_name": file_name,
                        "location": [
                            float(antenna_metadata[2]),
                            float(antenna_metadata[3]),
                            float(antenna_metadata[4]),
                        ],
                        "incident_power": incident_power,
                        "radiated_power": radiated_power,
                        "accepted_power": accepted_power,
                    }
                    items["element_pattern"][antenna_metadata[0]] = pattern

    items["model_info"] = []
    if model_info:
        if "object_list" in model_info:
            items["model_info"] = model_info["object_list"]

        required_array_keys = ["array_dimension", "component_objects", "lattice_vector", "cell_position"]

        if all(key in model_info for key in required_array_keys):
            items["component_objects"] = model_info["component_objects"]
            items["cell_position"] = model_info["cell_position"]
            items["array_dimension"] = model_info["array_dimension"]
            items["lattice_vector"] = model_info["lattice_vector"]

    with open_file(pyaedt_metadata_file, "w") as f:
        json.dump(items, f, indent=2)
    return str(pyaedt_metadata_file)


@pyaedt_function_handler()
def antenna_metadata_from_xml(input_file):
    """Obtain metadata information from metadata XML file.

    Parameters
    ----------
    input_file : str
        Full path to the XML file.

    Returns
    -------
    dict
        Metadata information.

    """
    # Load the XML file
    try:
        tree = defusedxml.ElementTree.parse(input_file)
    except ParseError:  # pragma: no cover
        logger.error(f"Unable to parse {input_file}.")
        return

    root = tree.getroot()
    element_patterns = root.find("ElementPatterns")

    sources = []
    if element_patterns is None:  # pragma: no cover
        print("Element Patterns section not found in XML.")
    else:
        cont = 0
        # Iterate over each Source element
        for source in element_patterns.findall("Source"):
            source_info = {
                "name": source.get("name"),
                "file_name": source.find("Filename").text.strip(),
                "location": source.find("ReferenceLocation").text.strip().split(","),
            }

            # Iterate over Power elements
            power_info = source.find("PowerInfo")
            if power_info is not None:
                source_info["incident_power"] = {}
                source_info["accepted_power"] = {}
                source_info["radiated_power"] = {}
                for power in power_info.findall("Power"):
                    freq = power.get("Freq")
                    source_info["incident_power"][freq] = {}
                    source_info["incident_power"][freq] = power.find("IncidentPower").text.strip()
                    source_info["accepted_power"][freq] = {}
                    source_info["accepted_power"][freq] = power.find("AcceptedPower").text.strip()
                    source_info["radiated_power"][freq] = {}
                    source_info["radiated_power"][freq] = power.find("RadiatedPower").text.strip()

            sources.append(source_info)
            cont += 1
    return sources
